Postgres JSONB를 관계형 컬럼과 함께 쓰는 기준
Postgres JSONB를 관계형 컬럼과 함께 쓰는 기준
JSONB는 형태가 자주 달라지는 부가 metadata와 외부 원문을 저장하는 데 유용하지만, 주문 상태·소유자·시각처럼 자주 필터링하고 관계·제약·정렬에 사용하는 값은 관계형 컬럼이 더 명확하다. JSONB를 선택한 뒤에도 내부 키의 타입과 버전은 관리해야 하며, GIN과 표현식 인덱스는 실제 연산자와 쿼리 패턴에 맞춰 선택해야 한다.
목차
- #문제가 되는 상황
- #JSON과 JSONB의 차이를 먼저 이해한다
- #관계형 컬럼에 둘 값의 기준
- #JSONB에 두기 좋은 값의 기준
- #한 테이블에서 두 모델을 함께 사용하기
- #JSON 내부에도 스키마와 버전은 존재한다
- #연산자에 따라 결과 타입이 달라진다
- #전체 JSONB GIN 인덱스의 역할
- #자주 쓰는 단일 경로는 표현식 인덱스를 검토한다
- #jsonb_ops와 jsonb_path_ops 선택
- #JSONB 부분 수정도 행 전체 갱신 비용을 생각한다
- #관계형 컬럼으로 승격하는 마이그레이션
- #JSONB가 적합하지 않은 신호
- #실행 계획과 운영 지표로 검증하기
- #결론
- #관련 노트
- #참고 자료
문제가 되는 상황
서로 다른 외부 공급자에서 들어오는 상품 이벤트를 저장한다고 하자. 공급자마다 추가 필드가 다르므로 모든 데이터를 JSONB 하나에 넣으면 초기 개발은 빠르다.
CREATE TABLE product_events (
id BIGSERIAL PRIMARY KEY,
payload JSONB NOT NULL
);
{
"tenantId": 42,
"eventType": "PRICE_CHANGED",
"occurredAt": "2026-09-02T10:00:00Z",
"productId": 501,
"currency": "KRW",
"oldPrice": 12000,
"newPrice": 9900,
"provider": {
"name": "market-a",
"traceId": "sample-trace"
}
}
처음에는 유연하지만 쿼리가 늘면 문제가 보인다.
SELECT payload
FROM product_events
WHERE payload ->> 'tenantId' = '42'
AND payload ->> 'eventType' = 'PRICE_CHANGED'
ORDER BY (payload ->> 'occurredAt')::timestamptz DESC;
tenantId 숫자가 문자열 비교로 바뀌고, occurredAt을 매번 변환하며, product 테이블과 외래 키를 선언하기 어렵다. 내부 키가 빠지거나 "42"와 42가 섞여도 DB가 자연스럽게 막아 주지 않는다.
JSONB가 나쁜 것이 아니라 핵심 관계형 데이터까지 가변 document로 취급한 것이 문제다.
JSON과 JSONB의 차이를 먼저 이해한다
PostgreSQL은 json과 jsonb 타입을 제공한다. 둘 다 JSON 입력을 받지만 저장과 처리 방식이 다르다.
| 항목 | json |
jsonb |
|---|---|---|
| 저장 성격 | 입력 텍스트 표현 보존 | 분해된 binary 표현으로 저장 |
| 키 순서·공백 | 원문 표현 보존 | 원문 공백·키 순서를 보존하지 않음 |
| 중복 키 | 원문에 남을 수 있음 | 처리 과정에서 하나의 값으로 정리 |
| 반복 질의·연산 | 매번 파싱 비용 가능 | 처리와 인덱싱에 유리 |
| GIN 등 인덱스 | 제한적 | 다양한 연산자 인덱싱 지원 |
API payload를 원문 그대로 감사 목적으로 보존해야 한다면 별도 raw text 또는 object storage가 필요할 수 있다. JSONB는 JSON 의미를 보존하지만 바이트 단위 원문 아카이브는 아니다.
일반적으로 내부에서 반복 조회하고 containment·key 검색을 사용할 document에는 JSONB가 더 자연스럽다. 단지 “JSON처럼 보인다”는 이유로 타입을 고르지 않고 원문 보존인지 질의 가능한 구조인지 목적을 분리한다.
관계형 컬럼에 둘 값의 기준
다음 질문 중 하나라도 자주 “예”라면 일반 컬럼 후보가 된다.
- 거의 모든 행에 존재하는가?
- WHERE, JOIN, ORDER BY, GROUP BY에서 자주 쓰는가?
NOT NULL,CHECK,UNIQUE,FOREIGN KEY로 규칙을 강제해야 하는가?- 정확한 타입과 단위를 보장해야 하는가?
- 파티션 키 또는 권한 경계로 사용하는가?
- 여러 서비스와 분석 쿼리에서 공통 의미를 갖는가?
앞의 이벤트는 다음처럼 바꿀 수 있다.
CREATE TABLE product_events (
id BIGSERIAL PRIMARY KEY,
tenant_id BIGINT NOT NULL,
product_id BIGINT NOT NULL,
event_type TEXT NOT NULL,
occurred_at TIMESTAMPTZ NOT NULL,
metadata JSONB NOT NULL DEFAULT '{}'::jsonb,
CONSTRAINT fk_product_events_product
FOREIGN KEY (product_id) REFERENCES products(id),
CONSTRAINT ck_product_events_type
CHECK (event_type IN ('CREATED', 'PRICE_CHANGED', 'DISCONTINUED')),
CONSTRAINT ck_product_events_metadata_object
CHECK (jsonb_typeof(metadata) = 'object')
);
CREATE INDEX ix_product_events_tenant_type_time
ON product_events(tenant_id, event_type, occurred_at DESC);
핵심 조회는 일반 B+Tree와 타입을 그대로 사용한다.
SELECT id, product_id, occurred_at, metadata
FROM product_events
WHERE tenant_id = 42
AND event_type = 'PRICE_CHANGED'
ORDER BY occurred_at DESC
LIMIT 100;
관계형 컬럼의 장점은 성능뿐 아니라 의도가 스키마에 보인다는 점이다. tenant_id가 빠질 수 없고 product가 존재해야 하며 occurred_at이 정확한 시간 타입이라는 사실을 모든 쓰기 경로에 강제한다.
JSONB에 두기 좋은 값의 기준
다음 특성은 JSONB와 잘 맞는다.
- 공급자별로 일부 키가 다름
- 대부분 조회하지 않고 표시·전달 목적으로 보관
- 새 부가 키가 자주 추가되지만 기존 행의 backfill이 불필요
- 중첩된 객체·배열 구조 자체가 의미 있음
- 외부 시스템 원문의 일부를 유연하게 보존
- 행마다 다른 optional attribute 집합
예를 들어 provider 부가 정보는 metadata에 둘 수 있다.
{
"schemaVersion": 2,
"provider": {
"name": "market-a",
"traceId": "sample-trace",
"receivedRegion": "ap-northeast"
},
"priceChange": {
"old": 12000,
"new": 9900,
"reason": "campaign"
}
}
다른 공급자는 additional flags를 넣을 수 있다.
{
"schemaVersion": 2,
"provider": {
"name": "market-b",
"batchId": "sample-batch"
},
"priceChange": {
"old": 12000,
"new": 9900
},
"flags": ["manual-review"]
}
JSONB에 두더라도 키 이름, 타입, 단위와 의미를 문서화한다. 단지 ALTER TABLE을 피하려는 이유로 모든 것을 metadata에 넣지 않는다.
한 테이블에서 두 모델을 함께 사용하기
관계형과 JSONB는 양자택일이 아니다. 핵심 식별·관계·조회 축은 컬럼에, 가변 부가 정보는 JSONB에 둔다.
flowchart LR
A[Product Event] --> B[관계형 컬럼]
A --> C[JSONB metadata]
B --> B1[tenant_id]
B --> B2[product_id FK]
B --> B3[event_type]
B --> B4[occurred_at]
C --> C1[provider-specific fields]
C --> C2[optional flags]
C --> C3[raw attributes]경계를 결정할 때 현재 쿼리뿐 아니라 미래의 공통 의미도 본다. metadata의 countryCode로 권한과 과금을 결정하기 시작했다면 더 이상 부가 정보가 아니다. 일반 컬럼으로 승격하고 제약과 인덱스를 두는 것이 낫다.
반대로 UI에서 한 번 표시하는 실험 플래그마다 컬럼을 추가하면 스키마가 불필요하게 넓어진다. 안정된 핵심과 변화하는 가장자리를 구분하는 것이 목적이다.
JSON 내부에도 스키마와 버전은 존재한다
JSONB는 DB가 고정된 열 정의를 요구하지 않을 뿐, 애플리케이션이 데이터 형태를 모른 채 사용할 수 있다는 뜻은 아니다.
{
"schemaVersion": 1,
"price": 9900
}
{
"schemaVersion": 2,
"price": {
"amount": 9900,
"currency": "KRW"
}
}
읽기 코드는 두 버전을 명시적으로 다룬다.
type MetadataV1 = {
schemaVersion: 1;
price: number;
};
type MetadataV2 = {
schemaVersion: 2;
price: { amount: number; currency: string };
};
function normalizeMetadata(input: MetadataV1 | MetadataV2): MetadataV2 {
if (input.schemaVersion === 1) {
return {
schemaVersion: 2,
price: { amount: input.price, currency: "KRW" },
};
}
return input;
}
쓰기 경로에서는 JSON Schema, 런타임 validator 또는 타입 변환으로 구조를 검증한다. DB CHECK로 최소한 객체 타입과 필수 상위 키를 확인할 수도 있다.
CONSTRAINT ck_metadata_shape CHECK (
jsonb_typeof(metadata) = 'object'
AND metadata ? 'schemaVersion'
AND jsonb_typeof(metadata -> 'schemaVersion') = 'number'
)
CHECK가 복잡한 모든 버전을 표현하게 만들면 유지보수가 어려워질 수 있다. DB에는 절대 깨지면 안 되는 최소 조건, 애플리케이션에는 상세한 버전별 validation을 둔다.
연산자에 따라 결과 타입이 달라진다
PostgreSQL에서 ->는 JSON/JSONB 값을, ->>는 text를 반환한다.
SELECT metadata -> 'provider' AS provider_object,
metadata ->> 'schemaVersion' AS version_text
FROM product_events;
중첩 경로는 #>와 #>>로 접근할 수 있다.
SELECT metadata #>> '{provider,name}' AS provider_name
FROM product_events;
숫자 비교를 text 상태로 하면 사전식 비교 문제가 생긴다.
WHERE metadata #>> '{priceChange,new}' > '900'
명시적으로 숫자로 변환한다.
WHERE (metadata #>> '{priceChange,new}')::numeric > 900
하지만 매 쿼리에서 변환하고 인덱싱해야 할 정도로 자주 쓰는 가격이라면 일반 numeric 컬럼으로 승격할 신호다.
키가 없거나 구조가 맞지 않으면 추출 연산자가 NULL을 반환할 수 있다. “키 없음”, JSON null, SQL NULL을 구분해야 한다.
SELECT metadata ? 'reason' AS has_key,
metadata -> 'reason' AS json_value,
metadata ->> 'reason' AS text_value
FROM product_events;
전체 JSONB GIN 인덱스의 역할
여러 키의 존재와 containment를 다양하게 검색한다면 전체 JSONB GIN 인덱스를 검토할 수 있다.
CREATE INDEX ix_product_events_metadata_gin
ON product_events USING GIN (metadata);
containment 연산자 @> 예시:
SELECT id
FROM product_events
WHERE metadata @> '{"provider":{"name":"market-a"}}'::jsonb;
최상위 키 존재 여부:
SELECT id
FROM product_events
WHERE metadata ? 'flags';
기본 jsonb_ops GIN operator class는 공식 문서 기준으로 ?, ?|, ?&, @>, @?, @@ 등 다양한 연산을 지원한다. 쿼리에서 사용하는 연산자가 인덱스 operator class와 맞아야 한다.
->> 등호 조건이 GIN을 자동으로 쓰는 것은 아니다
metadata ->> 'provider' = 'market-a'와 JSON containment는 다른 표현이다. 실행 계획에서 실제 인덱스 조건이 만들어지는지 확인한다.
GIN은 하나의 document에서 여러 key/value index item을 추출하므로 쓰기와 저장 비용이 크다. metadata가 크고 자주 변경되면 INSERT·UPDATE 지연, WAL과 vacuum 부담을 함께 측정해야 한다.
자주 쓰는 단일 경로는 표현식 인덱스를 검토한다
항상 provider name 하나만 등호 검색한다면 전체 JSONB GIN보다 작은 B+Tree 표현식 인덱스가 더 직접적일 수 있다.
CREATE INDEX ix_product_events_provider_name
ON product_events ((metadata #>> '{provider,name}'));
쿼리 표현식이 인덱스 정의와 맞아야 한다.
SELECT id, occurred_at
FROM product_events
WHERE metadata #>> '{provider,name}' = 'market-a'
ORDER BY occurred_at DESC;
tenant와 시간을 함께 자주 사용한다면 복합 표현식 인덱스도 후보가 된다.
CREATE INDEX ix_product_events_tenant_provider_time
ON product_events (
tenant_id,
(metadata #>> '{provider,name}'),
occurred_at DESC
);
하지만 provider name이 핵심 분류축으로 자리 잡았다면 일반 provider_name 컬럼이 더 명확할 수 있다. 표현식이 여러 인덱스와 쿼리에 반복되는 것은 관계형 승격 신호다.
jsonb_ops와 jsonb_path_ops 선택
PostgreSQL은 JSONB GIN에 대표적으로 두 operator class를 제공한다.
CREATE INDEX ix_metadata_ops
ON product_events USING GIN (metadata jsonb_ops);
CREATE INDEX ix_metadata_path_ops
ON product_events USING GIN (metadata jsonb_path_ops);
| 구분 | jsonb_ops |
jsonb_path_ops |
|---|---|---|
key 존재 ?, `? |
, ?&` |
지원 |
containment @> |
지원 | 지원 |
jsonpath @?, @@ |
지원 | 지원 |
| 유연성 | 높음 | 연산자 제한 |
| 일반적 특성 | key와 value를 폭넓게 인덱싱 | 지원 쿼리에 더 구체적·작을 수 있음 |
선택은 “path_ops가 더 빠르다”라는 한 줄이 아니라 실제 쿼리 연산자로 결정한다. key existence 검색이 필요하면 path_ops만으로 부족하다. 두 GIN을 모두 만드는 것은 쓰기와 저장 공간을 크게 늘리므로 대표 쿼리로 측정한다.
공식 동작과 operator 지원 범위는 PostgreSQL JSON Types 문서와 GIN Indexes 문서를 기준으로 확인한다.
JSONB 부분 수정도 행 전체 갱신 비용을 생각한다
JSONB의 한 경로만 바꾸는 함수가 있다.
UPDATE product_events
SET metadata = jsonb_set(
metadata,
'{provider,receivedRegion}',
'"ap-southeast"'::jsonb,
true
)
WHERE id = :id;
SQL 표현은 부분 수정처럼 보이지만 JSONB value와 행 버전, 관련 인덱스 갱신 비용은 생각해야 한다. MVCC에서는 UPDATE가 새 row version을 만들고 큰 JSONB가 외부 저장되면 TOAST와 WAL 비용도 생길 수 있다. GIN 인덱스가 있다면 document에서 추출한 index item 변경도 반영된다.
작은 flag 변경
→ 새 row version
→ JSONB value 변경
→ GIN/표현식 인덱스 갱신 가능
→ WAL·vacuum 비용
초당 자주 바뀌는 counter나 현재 상태를 큰 JSONB 안에 넣으면 쓰기 증폭이 커질 수 있다. 자주 갱신되는 작은 값을 일반 컬럼이나 별도 테이블로 분리한다.
CREATE TABLE product_event_processing_state (
event_id BIGINT PRIMARY KEY REFERENCES product_events(id),
retry_count INTEGER NOT NULL DEFAULT 0,
processed_at TIMESTAMPTZ NULL
);
불변에 가까운 원본 metadata와 mutable 처리 상태를 분리하면 경쟁과 vacuum 범위도 줄일 수 있다.
관계형 컬럼으로 승격하는 마이그레이션
metadata의 provider name을 핵심 필드로 승격한다고 하자. 한 번에 NOT NULL 컬럼을 추가하고 전체 데이터를 갱신하면 큰 lock과 WAL을 만들 수 있다. 단계적으로 진행한다.
- nullable 새 컬럼을 추가한다.
- 새 쓰기가 컬럼과 JSONB를 함께 기록하도록 배포한다.
- 작은 batch로 과거 행을 backfill한다.
- 불일치와 NULL을 검증한다.
- 새 컬럼 인덱스를 생성한다.
- 읽기 쿼리를 새 컬럼으로 전환한다.
- 필요하면 NOT NULL·CHECK를 추가한다.
- JSONB의 중복 키를 제거할지 호환 기간을 정한다.
ALTER TABLE product_events
ADD COLUMN provider_name TEXT NULL;
UPDATE product_events
SET provider_name = metadata #>> '{provider,name}'
WHERE id > :last_id
AND id <= :last_id + :batch_size
AND provider_name IS NULL;
검증 쿼리:
SELECT COUNT(*)
FROM product_events
WHERE provider_name IS DISTINCT FROM
metadata #>> '{provider,name}';
dual write 기간에는 두 값이 달라지는 실패를 관측한다. 가능하면 한 값을 source of truth로 정하고 다른 표현을 생성하거나 이벤트로 동기화한다.
JSONB가 적합하지 않은 신호
다음 현상이 반복되면 모델을 다시 본다.
- 모든 쿼리가 같은 JSON 키를 추출하고 cast함
- 같은 표현식 인덱스가 여러 조합으로 늘어남
- 내부 키를 FK와 JOIN에 사용해야 함
- JSON 키 누락 때문에 애플리케이션 방어 코드가 많음
- 숫자·날짜가 문자열과 JSON number로 섞임
- 하나의 작은 필드를 초당 여러 번 UPDATE함
- 분석팀이 매번 JSON path를 알아야 함
- payload가 너무 커 읽기·WAL·vacuum 비용이 커짐
- 같은 JSON 안의 배열 원소를 개별 행처럼 조회·수정함
특히 배열 안의 객체를 독립적으로 식별·JOIN·집계한다면 자식 테이블이 더 자연스럽다.
{
"items": [
{"productId": 10, "quantity": 2},
{"productId": 20, "quantity": 1}
]
}
CREATE TABLE order_items (
order_id BIGINT NOT NULL REFERENCES orders(id),
product_id BIGINT NOT NULL REFERENCES products(id),
quantity INTEGER NOT NULL CHECK (quantity > 0),
PRIMARY KEY (order_id, product_id)
);
JSONB 배열이 화면 스냅샷이라면 괜찮지만 재고와 매출 계산의 원본이라면 관계형 행이 제약과 집계에 유리하다.
실행 계획과 운영 지표로 검증하기
인덱스 후보별로 실제 쿼리를 비교한다.
EXPLAIN (ANALYZE, BUFFERS)
SELECT id, occurred_at
FROM product_events
WHERE metadata @> '{"provider":{"name":"market-a"}}'::jsonb;
EXPLAIN (ANALYZE, BUFFERS)
SELECT id, occurred_at
FROM product_events
WHERE metadata #>> '{provider,name}' = 'market-a';
확인할 항목:
- Seq Scan, Bitmap Index Scan, Index Scan 중 어떤 계획인가?
- 실제 읽은 row와 heap block은 몇 개인가?
- 조건값별 분포가 얼마나 치우쳤는가?
- GIN recheck와 false positive 비용은 어떤가?
- 인덱스 생성 후 INSERT·UPDATE p95는 얼마나 늘었는가?
- 인덱스 크기와 WAL 생성량은 얼마나 증가했는가?
- autovacuum이 dead tuple을 따라갈 수 있는가?
테스트 document는 키 개수와 중첩 깊이, 배열 크기, 특정 provider 쏠림을 운영과 비슷하게 만든다. 작은 { "a": 1 } 문서 100건으로 얻은 결과를 대형 metadata 수천만 건에 일반화하지 않는다.
“언젠가 어떤 키든 검색할 수 있게” 전체 GIN을 먼저 만들기보다 실제 query digest와 호출 빈도를 확인한다. 단일 경로 B+Tree, 관계형 승격, GIN 중 가장 작은 구조를 선택한다.
결론
PostgreSQL JSONB와 관계형 컬럼을 함께 쓰는 기준은 데이터가 JSON으로 표현 가능한가가 아니라 어떤 보장과 접근 패턴을 필요로 하는가다. 거의 모든 행에 존재하고 JOIN·필터·정렬·제약에 쓰는 tenant, product, event type과 시각은 일반 컬럼에 둔다. 공급자별로 달라지고 주로 보존·전달하는 부가 metadata는 JSONB가 잘 맞는다.
JSONB도 schemaVersion, 내부 타입과 validation이 필요하다. 다양한 containment·key 검색에는 GIN을, 자주 쓰는 단일 경로에는 표현식 B+Tree를 검토하되 operator class와 쿼리 연산자를 맞춰야 한다. 작은 경로 수정도 MVCC row version과 JSONB·GIN 갱신 비용을 만들 수 있다. 특정 키가 핵심 필드로 성장했다면 단계적인 backfill과 dual-read 검증을 거쳐 관계형 컬럼으로 승격하는 것이 장기적으로 더 단순하다.
관련 노트
- 정규화가 중복과 이상 현상을 줄이는 원리
- 반정규화를 고려해야 하는 시점
- 외래 키가 데이터 정합성을 지키는 방식
- CHECK Constraint와 애플리케이션 검증의 역할
- Covering Index로 테이블 접근 줄이기
- SQL vs NoSQL